在昨天我們已經順利的啟動 xv6 了,而在 xv6 中實現了 21 種 System call,今天我們將看到一些與 process 有關的 System call 使用,包含fork()
, exec()
, wait()
,以及 Process 大致上的概念 (不會提及到 Process 細節的部分,包含 PCB,記憶體布局等等)。
在分時系統中,CPU 會在許多工作之間不斷地進行切換而造成多工的錯覺,並且允許多個使用者使用同一台電腦的資源,每一個使用者都認為自己擁有電腦全部的資源 (隔離性)。而事實上並非如此,當有多個工作已經被放入記憶體時,我們想要加入新的工作卻發現記憶體空間不足時,就會面臨到哪一些工作需要優先完成的問題,也就是排班問題 (Scheduling)。
上面的用詞,載入到記憶體的工作,也就是到記憶體中實際執行的程式,我們稱為一個 Process,CPU 會在多個 Process 之間快速地進行切換。
Process本質上為正在執行中的程式,每一個 Process 都有一個記憶體空間 (address space),這是從某一個最小值 (在xv6中為0),到某一個存取記憶體地址最大值的表,在這個記憶體空間中,Process 可以進行讀寫,使得該記憶體空間中存有執行中的程式,程式的變數以及程式的 Stack 和 Heap,和一些暫存器 (Program Counter和指向Stack, Heap的指標)。
假設在一個分時系統中,我們正在解壓縮某一個檔案,在等待解壓縮的過程,我們使用瀏覽器進行上網,同時打開 Line 和朋友聊天,這時候我們便至少有了三個正在(活動)的 Process,此時作業系統會週期性的快速在各個 Process 之間進行切換。一個 Process 暫時取得到了 CPU 的資源,之後在下一個時刻需要取得資源時,該 Process 的狀態需要和上一次暫停時完全相同,也因此我們需要在Process 暫停時把一些資訊給保存下來,如果有涉及一些檔案操作,需要記錄該 Process 開啟哪一些檔案,與檔案有關的指標。在作業系統中,和 Process 有關的資訊,除了該 Process 相關的記憶體空間以外,會被存放到一張表中,稱為 Process table。
所以,一個獲得 CPU 資源的 Process 包含了以下元素。Process 的記憶體空間(有時候被稱為 core image),暫存器資訊以及一些重新啟動 Process 的資訊。
與 Process 有關的 System call 為建立 Process 的fork()
和結束 Process 的exit()
,舉例來說,有一個 Shell 的 Process,像是 bash 讀取使用者輸入的指令,假設使用者想要編譯一個程式,使用了以下指令gcc hello -o hello
,此時 bash 需要建立新的 Process 來處理這樣的指令,而當編譯結束後,需要執行一條 System,如exit()
來結束自己。如果一個 Process 可以像剛剛的例子中可以建立子 Process,而這一些子 Process 就可以建立他們的子 Process,我們可以得到一個 Process Tree。
< How to make a specific process tree using fork() >
而與Process有關的System call為fork()
, exec()
, wait()
。
Shell 在執行使用者輸入的指令時,大致上可以看做事以下步驟
fork()
System call 建立新的 Process 以及 Process table。exec()
替換掉目前 Process 的指令以及資料有一些指令並不會使用子 Process 執行 (不會使用fork()
等等),而是直接由親代 Process 執行,如cd
。
下面會介紹有關於fork()
以及exec()
等關於 Process 操作的 System call。
在xv6中,Process 的組成可以分為兩個部分,一個部分為 user space 中的 instrction, data, stack 等等,另外一部分為 kernel space (只能被kernel看見) 的 process 狀態。xv6 提供了分時系統的特性,也就是在可用的 CPU 資源之間不斷的切換,並決定哪一個等待中的Process 先被執行,當一個 Process 不在執行時,我們需要保存 Process 的資訊,xv6 會保存 CPU 的暫存器,當該 Process 被再次執行時會恢復暫存器的值,而 kernel 會將 Process 和 Pid (Process identifier) 綁定在一起。
前面提到可以通過fork()
建立一個 Process,而通過fork()
建立新的 Process 稱為子 Process,內容和親代 Process 相同 (但是親代 Process 和子 Process 使用的 CPU 暫存器不同以及記憶體空間不同,因此改變其中一個 Process 並不會影響到其他的 Process),fork()
在親代 Process 和子 Process 都會有回傳值。親代 Process 會回傳子 Process 的 PID,而子 Process 會回傳0。
// user/Tfork.c
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int main(void)
{
int pid = fork();
printf("fork() returned %d\n", pid);
if(pid == 0)
{
printf("child\n");
}
else
{
printf("parent\n");
}
exit(0);
}
output:
Tfork
fork() retfork() returned 0
child
urned 7
parent
說明:
在第8行的地方,我們呼叫了fork()
複製目前 Process 的記憶體內容(包含 instruction 和 data),並建立一個新的 Process,之後,我們便有了記憶體內容一模一樣的兩個 Process。
fork()
System call 在兩個 Process 中都會有回傳值,在原始的 Process 中,會回傳新建子 Process 的 PID,而在子 Process 中,會回傳0,我們可以通過fork()
的回傳值來區分兩個記憶體內容一模一樣的 Process。
在第10行,檢查 PID,如果 PID 為0,我們可以判斷它必然為子 Process,反之為親代 Process,之後兩個 Process 都會退出,執行並輸出結果我們會發現輸出一團一怪的輸出,因為 QEMU 實際上是在模擬一個多核心的 CPU,因此兩個 Process 會分別在不同的核心 (thread, hart) 中同時執行,並且一同在 Console 上一個一個 byte 的輸出,我們可以看出親代 Process 的 PID 為7,子 Process 的 PID 為0。
#include "kernel/types.h"
#include "user/user.h"
int main(void)
{
char *argv[] = {"echo", "this", "is", "echo", 0};
exec("echo", argv);
printf("exec failed!\n");
exit(0);
}
output:
this is echo
說明:
在第7行執行了exec()
System call,這個 System call 功用為載入一個 memory image file (在xv6中,memory image file 的格式為 ELF),並使用 argv 作為引數 (Argument) 並執行,且替換掉呼叫 exec 的 Process 的記憶體,在這裡就是 Shell 被替換掉,執行成功後不回到調用 exec 的 Process(因為呼叫exec()
Process 的記憶體已經被替換掉了),而是從 ELF 的程式入口處,以 argv 作為參數開始執行。以上面即為將調用的 Process 替換成 echo,並將 argv 作為參數傳入並執行。echo()
的功用為接收傳遞的參數作為輸入並且輸出,上面這一段程式的功用等價於echo this is echo
。
關於exec()
exec()
只有在執行發生錯誤的時候才會有回傳值,才會執行到第8行的"exec failed!",例如檔案不存在,回傳-1等等。exec()
會保留檔案描述子,所以在執行 exec()
之前呼叫的檔案描述子,都會在新的 Process 保留。上面這個程式碼為使用檔案中的另外一個程式取代自己。實際上,在 Shell 中執行 echo()
或是 ls()
我們不希望 Shell 被替換掉,如上面程式這個情況,執行完 echo()
之後就全部結束了。我們可以使用一種作法為 Shell 使用 fork()
產生出新的子 Process,接著這個子 Process 在執行 exec()
,這個方法在 UNIX 十分的常見,當我們想要執行完某一個程式後,拿回控制權,就會使用這樣的方式。
#include "kernel/types.h"
#include "user/user.h"
int main(void)
{
int pid = 0;
int status = 0;
pid = fork();
if(pid == 0)
{
char *argv[] = {"echo", "this", "is", "echo", 0};
exec("echo", argv);
printf("exec failed!\n");
exit(1);
}
else
{
printf("parent waiting\n");
wait(&status);
printf("this child exited with status %d\n", status);
}
exit(0);
}
output:
$ Texec
parent waiting
this is echo
this child exited with status 0
說明:
這裡發生了神奇的事情,居然是先印出了第19行的 parent waiting,接著印出第14行的 this is echo,這和我們程式的順序不同,原因和先前 fork()
的情況類似,exec()
是一個需要很多操作,且花費較大時間代價的操作,因此,可能會發生親代 Process 在 echo 執行完之前,就已經完成執行了。
在第9行的地方呼叫了 fork()
,產生出了一個和親代 Process 相同的子 Process,接著該子 Process 呼叫了 exec()
,子 Process 變成了 echo,以 argv 作為參數執行,echo 執行完之後便會退出,此時親代 Process 便重新獲得了控制,fork()
會在親代 Process 中回傳子 Process 的 PID,因此 PID 大於0,進入第19行的部分接續執行。
第20行親代 Process 會等待子 Process 退出,wait()
以一個整數的記憶體地址作為引數傳入,這個參數為做為和親代 Process 之間通訊的方式,而 echo()
成功結束,執行 exit(0)
,這個0會傳到第23行親代 Process 的 wait()
,wait()
將 status 的記憶體地址傳遞到 kernel 中,kernel 會將 status 寫入子 Process 向exit()
傳入的引數,因此 status 寫入0,也因此印出了"this child exited with status 0"。
在UNIX風格中,exit(0)
表示成功結束 Process,exit(1)
表示結束 Process 失敗。親代 Process 可以通過讀取 wait()
的引數來得知子 Process 是否成功結束。
而我們可以試試看如果子 Process 結束失敗會發生什麼事情,這裡我們需要讓 exec()
執行失敗,回傳-1,我們通過錯誤的檔案名稱來實現
#include "kernel/types.h"
#include "user/user.h"
int main(void)
{
int pid = 0;
int status = 0;
pid = fork();
if(pid == 0)
{
char *argv[] = {"echo", "this", "is", "echo", 0};
exec("echoooooo", argv);
printf("exec failed!\n");
exit(1);
}
else
{
printf("parent waiting\n");
wait(&status);
printf("this child exited with status %d\n", status);
}
exit(0);
return 0;
}
output:
$ Texec
parent waiting
exec failed!
this child exited with status 1
說明:
第13行子 Process 執行 exec()
由於找不到檔案因此失敗並回傳,接著執行到第15行執行 exit(1)
修改了 status 的值
因此最後輸出的結果為"this child exited with status 1",得知子 Process 執行失敗。
而其實上面這是一個沒有效率的寫法,我們通過 fork()
複製了整個 Process,之後又使用 exec()
丟棄,如果該 Process 十分巨大,我們 fork()
需要花費大量的時間,後面將會通過一些方法對這樣得行為進行優化。
上面提到可以通過 Shell 執行一次 fork()
後接著 exec()
使得親代 Process 擁有控制權,而我們可以看看在 xv6 Shell 中是如何執行的
.
.
.
// /user/sh.c
int main(void)
{
static char buf[100];
int fd;
// Ensure that three file descriptors are open.
while((fd = open("console", O_RDWR)) >= 0){
if(fd >= 3){
close(fd);
break;
}
}
// Read and run input commands.
while(getcmd(buf, sizeof(buf)) >= 0){
if(buf[0] == 'c' && buf[1] == 'd' && buf[2] == ' '){
// Chdir must be called by the parent, not the child.
buf[strlen(buf)-1] = 0; // chop \n
if(chdir(buf+3) < 0)
fprintf(2, "cannot cd %s\n", buf+3);
continue;
}
if(fork1() == 0)
runcmd(parsecmd(buf));
wait(0);
}
exit(0);
}
.
.
.
int fork1(void)
{
int pid;
pid = fork();
if(pid == -1)
panic("fork");
return pid;
}
在第19行有一個 while 迴圈,讀取使用者輸入到 Shell 的指令,接著會進入到第27行呼叫 fork1()
,該函式會呼叫 fork()
產生出一個子 Process。
子 Process 會呼叫 runcmd()
void runcmd(struct cmd *cmd)
{
int p[2];
struct backcmd *bcmd;
struct execcmd *ecmd;
struct listcmd *lcmd;
struct pipecmd *pcmd;
struct redircmd *rcmd;
if(cmd == 0)
exit(1);
switch(cmd->type){
default:
panic("runcmd");
case EXEC:
ecmd = (struct execcmd*)cmd;
if(ecmd->argv[0] == 0)
exit(1);
exec(ecmd->argv[0], ecmd->argv);
fprintf(2, "exec %s failed\n", ecmd->argv[0]);
break;
.
.
.
}
在第21行子 Process 呼叫 exec()
,執行完畢後,指呼叫 exit()
結束子 Process,並且親代 Process 收到 wait()
回傳值。
在一次只能執行一個程式的系統中 (如批次系統),記憶體中一次只能有一個程式,如果要執行第二支程式,需要將第一支程式移出記憶體,再將第二支程式載入到記憶體中,而在現代作業系統中,我們希望能夠執行多個程式,而為了避免這一些程式在記憶體中互相干擾,我們需要做到一些隔離機制,也就是上面提到作業系統的獨立性的目標。
上面這個目標涉及對於記憶體的管理以及保護,另外一種為管理每一個 Process 有關的記憶體空間,每一個 Process 有他們可以使用的記憶體空間,從某一個最小值到某一個最大值,當一個 Process 他的記憶體空間等於甚至超過了物理記憶體空間,我們需要一些機制進行處理,這時候我們可能需要虛擬記憶體的技術,把一些暫時不需要的資訊存入到硬碟中 (這個技術稱為 swapping)。
xv6-riscv
Operating System Concepts, 9/e
RISC-V xv6 Book